Skip to content

OpenCode custody: static-key slots served from the vault through an in-process auth proxy - #28

Merged
ualtinok merged 1 commit into
cortexkit:masterfrom
legion-works:feat/opencode-custody
Sep 4, 2026
Merged

OpenCode custody: static-key slots served from the vault through an in-process auth proxy#28
ualtinok merged 1 commit into
cortexkit:masterfrom
legion-works:feat/opencode-custody

Conversation

@iceteaSA

@iceteaSA iceteaSA commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Implements the plugin design from #17, static-key slice: OpenCode type:"api" providers (deepseek, synthetic, minimax-coding-plan, …) get their keys moved into the vault, the auth.json entry becomes a non-secret tombstone, and a plugin serves the real key per request from a capability handle.

How it works (source-verified on stock OpenCode 1.18.25; the spike in scripts/spikes/ is the executed proof):

  • The plugin registers through the config hook by injecting { apiKey: sentinel, fetch } into cfg.provider[id].options. Config provider options are re-applied after every auth.loader (provider.ts:1643-1650), so the injected fetch also replaces a shipped plugin's — no fork, no per-provider knowledge.
  • The fetch is an in-process auth proxy: it rewrites every header value and URL query value equal to the sentinel with the vault-served material, forwards, and observes the response. 401 → report_auth_failure with the record_version that was actually served, then the next account; 429/402 → cooldown, next; 403/5xx → returned as-is (not a credential verdict).
  • Ownership is a conjunction: tombstone in auth.json (absence of a local credential) AND serve: "opencode-claustrum" in the handle file (who owns the slot). The seven cells are explicit; a real key sitting behind an owned slot gets a refusing fetch, never either copy.
  • Multiple keys per provider: apikey:<provider>:<label>, handle-file order is failover order.
  • Redirects are followed manually and same-origin only; cross-origin redirects are refused so a substituted x-api-key never leaves the configured origin.

CLI (ck auth, admin gate, key never on argv): migrate-opencode [--dry-run|--replace|--restore <provider>] [--provider …]... [--serve-by …] and opencode-account add|remove|list. Idempotent nine-step transaction; the handle-file superseded journal makes a crash between tombstone and revoke converge on rerun. Compare/restore read material through the ordinary consumer credential.get with a handle the CLI minted — no new admin op, nothing secret-returning added to the admin surface.

Packages (bun workspace beside the cargo one; CI gets setup-bun): @cortexkit/claustrum-client — detect / identity / wire / errors extracted from anthropic-auth's soak-proven client, policy-free; @cortexkit/opencode-claustrum — the plugin. packages/opencode/golden/{tombstone,handles}.json are the single cross-language source; Rust pins them with include_str!, TS imports them, anthropic-auth vendors them by SHA (cortexkit/anthropic-auth#182).

Verification: scripts/gate.sh green (workspace floor 501 + 57 bun tests + two crash-seam arms; release binary is seam-free). scripts/accept-opencode-custody.sh ran against the live daemon on a scratch XDG home: migrate → real routed request served through the vault → hand-restored key refused as split custody (sentinel never sent) → --restore round-trips the key and revokes the handle; oauth:anthropic* and legacy apikey:* byte-identical throughout. Two defects only the live arm found (OpenCode treats every export of a plugin module as a plugin; "inject nothing" on the split cell let stock OpenCode serve the local key) are fixed with tests.

Out of scope, seams left: OAuth main slots (xai is proven to take the same fetch; anthropic stays with anthropic-auth by serve), Claude Code / Codex.

Design doc: docs/opencode-custody-design.md. Draft until the maintainer has had a look at the admin/CLI surface.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Summary by cubic

Implements #17’s static-key custody slice for OpenCode: type:"api" credentials move from auth.json into the vault, leaving a non-secret tombstone while @cortexkit/opencode-claustrum serves keys through an in-process fetch proxy. Existing installations require migration; unsupported provider shapes and split custody fail closed, while Anthropic OAuth remains owned by anthropic-auth.

CLI and safety

  • Adds admin-gated ck auth migrate-opencode with dry-run, provider selection, restore, replace, and force-shape modes, plus opencode-account add|remove|list.
  • Atomically writes mode-0600 auth and handle files, with crash recovery that converges without rotating handles.
  • Refuses tombstones as import credentials and validates provider, handle, and secret-bearing file data consistently across Rust and TypeScript.
  • Supports ordered account failover, record-version-fenced 401 reporting, 429/402 cooldowns, and same-origin-only redirects.
  • Warns when a provider’s tombstoned shape changes, and --restore returns the credential to auth.json while revoking its handle.

Verification

  • Adds @cortexkit/claustrum-client, @cortexkit/opencode-claustrum, hermetic Bun tests, and coverage for the shipped plugin bundle.
  • Adds Rust CLI and crash-seam tests, Windows CI coverage, and live daemon acceptance tests for migration, serving, split-custody refusal, and restore.

Written for commit 5d2e43e. Summary will update on new commits.

Review in cubic

@socket-security

socket-security Bot commented Sep 2, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addednpm/​@​types/​bun@​1.3.141001004890100
Addednpm/​@​opencode-ai/​plugin@​1.18.251001007097100
Addednpm/​@​cortexkit/​subc-client@​0.8.18810010093100
Addednpm/​typescript@​7.0.29910089100100

View full report

@socket-security

socket-security Bot commented Sep 2, 2026

Copy link
Copy Markdown

Warning

Review the following alerts detected in dependencies.

According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.

Action Severity Alert  (click "▶" to expand/collapse)
Warn High
Obfuscated code: npm json-schema is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: packages/opencode/package.jsonnpm/@opencode-ai/plugin@1.18.25npm/json-schema@0.4.0

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/json-schema@0.4.0. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

View full report

@iceteaSA
iceteaSA force-pushed the feat/opencode-custody branch from f1f7f5b to abe93be Compare September 2, 2026 09:20
@iceteaSA
iceteaSA marked this pull request as ready for review September 2, 2026 09:20
iceteaSA added a commit to iceteaSA/anthropic-auth that referenced this pull request Sep 2, 2026
Bytes unchanged (verified IDENTICAL x2); the previous pin named a commit
off cortexkit/claustrum#28's history after its squash.

Also: biome check in the pre-commit hook errored when every staged path
was ignored, so any golden-only bump commit failed the hook. Pass
--no-errors-on-unmatched.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4 issues found across 56 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/opencode/src/plugin.ts">

<violation number="1" location="packages/opencode/src/plugin.ts:117">
P2: When a malformed auth source contains many sentinel-like strings, the recovery scan processes every hit and creates one refusal provider per hit without a cap. Bound the number of scan hits and use a fail-closed fallback for the capped case so corrupted input cannot exhaust OpenCode during configuration.</violation>
</file>

<file name="scripts/spikes/opencode-config-fetch.sh">

<violation number="1" location="scripts/spikes/opencode-config-fetch.sh:236">
P2: The wire assertion can pass without the sentinel in `Authorization`. Match records by their actual `headers.authorization` value, not by sentinel text anywhere in the serialized request.</violation>
</file>

<file name="packages/opencode/src/freshness.ts">

<violation number="1" location="packages/opencode/src/freshness.ts:221">
P2: When `credential.get` returns `context_overflow`, this branch retries the unchanged OAuth `minTtlMs` after a 60-second backoff. Handle this class separately by reducing the request or making the slot unusable instead of retrying the same failing demand.</violation>
</file>

<file name="crates/credentials-module/tests/cli_admin.rs">

<violation number="1" location="crates/credentials-module/tests/cli_admin.rs:20">
P3: After moving tmp_root into this module, unique_temp_dir's doc comment still points at `cli_admin::tmp_root`, which no longer exists (it now lives in `common`). Update the reference to `tmp_root` so the cross-reference stays accurate.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/client/src/detect.ts Outdated
Comment thread packages/opencode/src/freshness.ts
Comment thread packages/opencode/src/plugin.ts Outdated
Comment thread scripts/accept-opencode-custody.sh
Comment thread packages/opencode/src/serve.ts
Comment thread packages/opencode/package.json
Comment thread crates/credentials-module/tests/common/mod.rs Outdated
Comment thread packages/opencode/README.md Outdated
Comment thread packages/opencode/README.md Outdated
Comment thread scripts/accept-opencode-custody.sh Outdated
iceteaSA added a commit to iceteaSA/anthropic-auth that referenced this pull request Sep 2, 2026
Bytes unchanged (verified IDENTICAL x2); the previous pin named a commit
off cortexkit/claustrum#28's history after its squash.

Also: biome check in the pre-commit hook errored when every staged path
was ignored, so any golden-only bump commit failed the hook. Pass
--no-errors-on-unmatched.
@ckcred-alfonso

ckcred-alfonso Bot commented Sep 2, 2026

Copy link
Copy Markdown

Reviewed the admin/CLI surface, which is what you asked for. Verified the two structural claims at source rather than reading them, and found one gap in the handle lifecycle.

The two claims hold

"No new admin op, nothing secret-returning added to the admin surface." crates/credentials-core/src/admin_ops.rs is byte-identical to master across this PR — the diff is empty. The op vocabulary, the MAC transcript shape, and Gate 2 are untouched. That was the claim I most wanted to be true and least wanted to take on trust.

The 163 lines out of admin_client.rs are a transport extraction, not a gate change. The removed items are catalog_has_vault, route_open, control_request, route_request, read_control_response, read_route_response, read_matching, error_reason — plumbing, now shared in route_client.rs. Nothing matching mac|challenge|nonce|hmac|verify was deleted. A −163 in the file that holds the challenge-response is exactly the diff stat that deserves reading, and it reads clean.

Reading material back through the ordinary consumer credential.get with a CLI-minted handle is the right call. It keeps the admin surface non-secret-returning, which is the property that makes the admin plane safe to reason about.

The gap: a minted handle can outlive the operation that minted it

Both directions share a shape — mint, then persist, then use:

// restore
handle = mint_handle(global, &account.credential_id)?;
update_specific_handle(&mut handles, provider, &account.label, &handle)?;
write_and_verify_handles(&args.handle_file, &handles)?;
get_material(global, &handle)?

// migrate
None => mint_handle(global, &id)?,
if old_handle.is_none() {
    update_handle(&mut handles, ...)?;
    write_and_verify_handles(&args.handle_file, &handles)?;

If update_* or write_and_verify_handles returns Err, the ? propagates and the handle is already minted, live, and recorded in no file. Not a leak of material, but a live bearer capability for a credential, held by nobody and tracked by nothing the tool will read again.

The window is narrow — the mint has to succeed and a local file write has to fail — and it is not unrecoverable: the mint is in the audit chain, so ck auth audit finds it and ck auth revoke-all-handles <id> closes it. But nothing tells an operator to look, and a handle file that does not mention the handle is precisely where they would look first.

What makes it worth fixing rather than documenting is that your superseded journal already solves the adjacent case and cannot solve this one. It converges a handle that was replaced — recorded before the revoke, so a crash between the two reconciles on rerun. A handle minted before it reaches the file has no such record; the journal's own precondition is the thing that failed.

The cheapest shape that closes it is the journal's own: write the intent before the effect. Record the id in the handle file (or the superseded list) before calling mint_handle, so a rerun has something to reconcile against, or revoke on the error path before propagating. I would take either; the second is smaller and the first composes with what you already built.

Two smaller notes

ServedCredential carries payload: Vec<u8> and restore lifts it into a String for auth.json. That is the zeroize half of #29 arriving in new code rather than a defect in this PR, and I would rather it landed as its own change than got bolted on here — but it is worth knowing that the material now has a second uncleared home in CLI memory.

Your redacted Debug on ServedCredential is the convention done right, and it is what prompted me to check the rest: VaultRecord and AdminOpBody both derived Debug over secret material. Fixed on master at 0679dea, including one your issue did not name — AdminOpBody::RevokeHandle.handle is the raw ckh_ bearer, not a hash. Worth a rebase before you take this out of draft.

What I have not reviewed

The TypeScript half, the plugin's fetch proxy, and the 40 automated findings. Two of those findings look like real defects to me on their face — the ./server export pointing at the plugin entry rather than serve.js, and the lifecycle.test.ts byte-identical assertion that JSON.stringify makes vacuous by dropping function-valued properties. That second one is the shape I would want closed before merge regardless of my opinion of the rest: a test that cannot fail is worse than an absent one.

@iceteaSA
iceteaSA force-pushed the feat/opencode-custody branch from abe93be to f28e419 Compare September 2, 2026 10:53

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

10 issues found across 58 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/client/src/errors.ts">

<violation number="1" location="packages/client/src/errors.ts:1">
P2: Because `ERROR_CLASS_WIRE_SET` is a mutable exported array, a JavaScript consumer can alter error decoding and action selection at runtime. Freeze the array (or use a private immutable membership set) before using it for wire validation.</violation>
</file>

<file name="packages/opencode/src/plugin.ts">

<violation number="1" location="packages/opencode/src/plugin.ts:367">
P1: When native LLM mode is enabled, this guard does not cover real-credential and other refusal paths, which only install a rejecting generic fetch. The native runtime bypasses that hook and can send the existing `options.apiKey`; apply the native-mode fail-closed handling before the tombstone split branch and ensure every refusal path blocks the native credential path.</violation>
</file>

<file name="scripts/spikes/opencode-config-fetch.sh">

<violation number="1" location="scripts/spikes/opencode-config-fetch.sh:9">
P2: When `OPENCODE_AUTH_CONTENT` is inherited, OpenCode ignores this fixture's `auth.json`, potentially consuming a real credential and making the assertions nondeterministic. Unset the auth-content override before launching OpenCode.</violation>

<violation number="2" location="scripts/spikes/opencode-config-fetch.sh:16">
P2: Deleting the directory returned by `mktemp` forfeits its ownership guarantee and creates a `/tmp` pathname race that can redirect the fixture's writes outside the temporary directory. Keep the directory created by `mktemp` and remove this second `rm -rf`.</violation>
</file>

<file name="packages/client/src/wire.ts">

<violation number="1" location="packages/client/src/wire.ts:74">
P2: When the handle is revoked or unknown, the daemon intentionally omits `record_version` from `credential.status`; this decoder rejects that valid response as `invalid_status`. Make `recordVersion` optional and validate it only when present.</violation>
</file>

<file name="scripts/accept-opencode-custody.sh">

<violation number="1" location="scripts/accept-opencode-custody.sh:53">
P2: When a post-migration check fails, cleanup preserves `$ROOT` containing plaintext API-key files for diagnostics. Scrub `$AUTH_FILE`, `$PRIVATE_ENTRY`, and other restored secret copies before retaining logs, or remove the scratch directory on failure.</violation>

<violation number="2" location="scripts/accept-opencode-custody.sh:56">
P2: When SIGINT or SIGTERM arrives between commands, `cleanup` can exit with the preceding zero status and report an interrupted acceptance as successful. Install signal traps that exit with 130/143 and let the EXIT trap perform cleanup.</violation>
</file>

<file name="scripts/gate.sh">

<violation number="1" location="scripts/gate.sh:125">
P2: The new `opencode-test-seam` arms in gate.sh have no counterparts in `.github/workflows/ci.yml`, violating this file's own invariant ("THE SET MUST MATCH CI... Every arm here corresponds to a step in .github/workflows/ci.yml; when a step is added there, add it here."). CI's "Clippy (conformance seams)" step still passes `--features kill9-test-seam,rotate-test-seam,login-test-seam,migration-tools` without `opencode-test-seam`, and no CI step runs the two new `run_expect 1` cli_opencode crash-cut tests — the `#[cfg(feature = "opencode-test-seam")]` tests at `crates/credentials-module/tests/cli_opencode.rs:1142` and `:1430` are compiled out of every CI cargo invocation. The seam-gated code in `opencode_migration.rs`/`opencode_accounts.rs` is therefore never compiled or linted in CI, and the tombstone-reread and handle-write crash-cut tests never run there; a regression in either would pass CI and only fail the local gate — the exact silent-divergence failure mode the gate header describes. Add `opencode-test-seam` to the CI clippy features and add CI steps mirroring the two `run_expect 1` arms (e.g. alongside the existing "Security-conformance suite" step).</violation>
</file>

<file name="packages/opencode/src/handles.ts">

<violation number="1" location="packages/opencode/src/handles.ts:161">
P1: A group-writable, non-sticky handle parent passes this check, so another group member can replace the capability file. Reject group or world-writable parents unless sticky-bit protection applies.</violation>
</file>

<file name="crates/credentials-core/src/oauth.rs">

<violation number="1" location="crates/credentials-core/src/oauth.rs:369">
P3: The second suggested command, `migrate-opencode --restore`, is not a valid invocation — the verb is `ck auth migrate-opencode --restore <provider>` (as written in this PR's operator-runbook change and design doc §5). An operator following the message would run a nonexistent command. Drop the bare form or write the full `ck auth migrate-opencode --restore <provider>`.</violation>
</file>

Requires human review: Auto-approval blocked because this review re-detected 2 unresolved issues already reported by Cubic.

Re-trigger cubic


// The native runtime reads `provider.options.apiKey` directly instead of this fetch
// seam. Its case-sensitive flag parser therefore gets an allowlist, not a best guess.
if (nativeLlmEnabled(process.env.OPENCODE_EXPERIMENTAL_NATIVE_LLM)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When native LLM mode is enabled, this guard does not cover real-credential and other refusal paths, which only install a rejecting generic fetch. The native runtime bypasses that hook and can send the existing options.apiKey; apply the native-mode fail-closed handling before the tombstone split branch and ensure every refusal path blocks the native credential path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/plugin.ts, line 367:

<comment>When native LLM mode is enabled, this guard does not cover real-credential and other refusal paths, which only install a rejecting generic fetch. The native runtime bypasses that hook and can send the existing `options.apiKey`; apply the native-mode fail-closed handling before the tombstone split branch and ensure every refusal path blocks the native credential path.</comment>

<file context>
@@ -0,0 +1,434 @@
+
+          // The native runtime reads `provider.options.apiKey` directly instead of this fetch
+          // seam. Its case-sensitive flag parser therefore gets an allowlist, not a best guess.
+          if (nativeLlmEnabled(process.env.OPENCODE_EXPERIMENTAL_NATIVE_LLM)) {
+            const observed = process.env.OPENCODE_EXPERIMENTAL_NATIVE_LLM;
+            const refusal = new CustodyNativeRuntimeError(
</file context>

Comment thread packages/opencode/src/freshness.ts Outdated
Comment on lines +161 to +162
if ((parent.mode & 0o002) !== 0 && (parent.mode & 0o1000) === 0) {
invalid("handle file parent is world-writable without sticky bit");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: A group-writable, non-sticky handle parent passes this check, so another group member can replace the capability file. Reject group or world-writable parents unless sticky-bit protection applies.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/handles.ts, line 161:

<comment>A group-writable, non-sticky handle parent passes this check, so another group member can replace the capability file. Reject group or world-writable parents unless sticky-bit protection applies.</comment>

<file context>
@@ -0,0 +1,196 @@
+    if (expectedUid !== undefined && parent.uid !== undefined && parent.uid !== expectedUid) {
+      invalid("handle file parent is not owned by the current uid");
+    }
+    if ((parent.mode & 0o002) !== 0 && (parent.mode & 0o1000) === 0) {
+      invalid("handle file parent is world-writable without sticky bit");
+    }
</file context>
Suggested change
if ((parent.mode & 0o002) !== 0 && (parent.mode & 0o1000) === 0) {
invalid("handle file parent is world-writable without sticky bit");
if ((parent.mode & 0o022) !== 0 && (parent.mode & 0o1000) === 0) {
invalid("handle file parent is group/world-writable without sticky bit");

@@ -0,0 +1,75 @@
export const ERROR_CLASS_WIRE_SET = [

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Because ERROR_CLASS_WIRE_SET is a mutable exported array, a JavaScript consumer can alter error decoding and action selection at runtime. Freeze the array (or use a private immutable membership set) before using it for wire validation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/client/src/errors.ts, line 1:

<comment>Because `ERROR_CLASS_WIRE_SET` is a mutable exported array, a JavaScript consumer can alter error decoding and action selection at runtime. Freeze the array (or use a private immutable membership set) before using it for wire validation.</comment>

<file context>
@@ -0,0 +1,75 @@
+export const ERROR_CLASS_WIRE_SET = [
+  'transient',
+  'permanent',
</file context>

Comment thread packages/client/src/identity.ts Outdated
Comment thread packages/client/src/tests/client.test.ts Outdated
Comment thread packages/client/README.md Outdated
Comment thread packages/opencode/src/request.ts Outdated
Comment thread packages/client/src/detect.ts
Comment thread crates/credentials-core/src/oauth.rs Outdated
}
ImportError::CustodyTombstone => write!(
f,
"refusing Claustrum tombstone material; run ck auth migrate-opencode or migrate-opencode --restore"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The second suggested command, migrate-opencode --restore, is not a valid invocation — the verb is ck auth migrate-opencode --restore <provider> (as written in this PR's operator-runbook change and design doc §5). An operator following the message would run a nonexistent command. Drop the bare form or write the full ck auth migrate-opencode --restore <provider>.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/credentials-core/src/oauth.rs, line 369:

<comment>The second suggested command, `migrate-opencode --restore`, is not a valid invocation — the verb is `ck auth migrate-opencode --restore <provider>` (as written in this PR's operator-runbook change and design doc §5). An operator following the message would run a nonexistent command. Drop the bare form or write the full `ck auth migrate-opencode --restore <provider>`.</comment>

<file context>
@@ -350,6 +364,10 @@ impl std::fmt::Display for ImportError {
             }
+            ImportError::CustodyTombstone => write!(
+                f,
+                "refusing Claustrum tombstone material; run ck auth migrate-opencode or migrate-opencode --restore"
+            ),
         }
</file context>
Suggested change
"refusing Claustrum tombstone material; run ck auth migrate-opencode or migrate-opencode --restore"
"refusing Claustrum tombstone material; run ck auth migrate-opencode or ck auth migrate-opencode --restore <provider>"

iceteaSA added a commit to iceteaSA/anthropic-auth that referenced this pull request Sep 2, 2026
Bytes unchanged (verified IDENTICAL x2); the previous pin named a commit
off cortexkit/claustrum#28's history after its squash.

Also: biome check in the pre-commit hook errored when every staged path
was ignored, so any golden-only bump commit failed the hook. Pass
--no-errors-on-unmatched.
@iceteaSA
iceteaSA force-pushed the feat/opencode-custody branch from f28e419 to 898b131 Compare September 2, 2026 11:50
@iceteaSA

iceteaSA commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for reading the admin surface at that level — the −163 in admin_client.rs was the diff stat I most wanted someone to open.

Handle lifecycle — fixed, 898b131. You were right that the superseded journal cannot cover it; its precondition is the thing that fails. I took the smaller shape: two helpers in opencode_migration.rs, and no bare mint_handle call remains outside them (grep -n 'mint_handle(' src/bin/cli_support/*.rs shows the definition and the two helpers' internals only).

  • mint_then_persist(global, id, |handle| …) — mints, runs the persist closure (which returns only after write_and_verify_handles succeeded), and revokes the handle if the closure fails. Used at the four migrate/restore sites and opencode-account add.
  • with_scoped_handle(global, id, |handle| …) — for the comparison-only handle add mints to check an existing record's material; revoked on every exit (success, mismatch, read failure). That site was a sixth instance of the same shape, one your grep and my first pass both stopped short of.
  • If the revoke itself fails, the propagated error names the credential id and the two closing commands (ck auth audit, ck auth revoke-all-handles <id>) — never silent. add's previous let _ = revoke_handle(...) is gone.

Tests under the opencode-test-seam feature (compiles nothing into release; strings count 0 after the gate's default rebuild): a seam-injected write failure at each site → non-zero exit, zero live handles for the id in the scratch store, mint_handle then revoke_handle in the chain; a seam-injected revoke failure → stderr carries the id and both remedies. Mutation: removing the revoke reddens the write-failure tests; removing the remedy text reddens the revoke-failure test. A SIGKILL between mint and write is out of scope for these — the crash-cut suites and ck auth audit cover that, and the helper's doc says so.

Rebased onto 0679dea. Thanks for closing RevokeHandle.handle too — the raw bearer one, which #29 had not named.

The two bot findings you flagged (./server export → serve.js; the lifecycle.test.ts byte-identical assertion made vacuous by JSON.stringify dropping the injected fetch) were fixed in f28e419 along with the rest of that review — 23 fixed, 12 nits, 3 declined by design with the reason in the commit body, 2 stale. The lifecycle test now asserts the injected fetch refuses.

Zeroize — agreed it should land as its own change against #29 rather than here; restore lifting the payload into a String for auth.json is a second uncleared home for the material in CLI memory, and I'd rather that be one deliberate PR than a rider on this one.

Branch is one commit on top of master; the review-round history is on legion-works:backup/opencode-custody-presquash{,2,3} if you want the per-round diffs.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 existing issues remain and 1 new issue found across 58 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/opencode/package.json">

<violation number="1" location="packages/opencode/package.json:23">
P2: OpenCode typecheck fails on a fresh checkout because it resolves `@cortexkit/claustrum-client` types from the client package's `dist/index.d.ts`, but CI runs typecheck before build and `dist` is gitignored and never committed. Build the client before typechecking opencode (e.g. reorder CI to build before typecheck, or have the root typecheck build the client's dist first), otherwise every clean CI run errors on TS2307 for the import in `plugin.ts`.</violation>
</file>

Requires human review: Auto-approval blocked because this review re-detected 11 unresolved issues already reported by Cubic.
Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/client/src/detect.ts Outdated
Comment thread packages/opencode/src/tombstone.ts
Comment thread packages/opencode/src/freshness.ts Outdated
Comment thread packages/opencode/src/plugin.ts Outdated
Comment thread packages/opencode/src/serve.ts Outdated
Comment thread packages/opencode/src/freshness.ts Outdated
Comment thread docs/opencode-custody-design.md Outdated
Comment thread crates/credentials-module/tests/cli_opencode.rs Outdated
Comment thread packages/opencode/src/tests/serve.test.ts Outdated
Comment thread scripts/gate.sh Outdated
iceteaSA added a commit to iceteaSA/anthropic-auth that referenced this pull request Sep 2, 2026
Bytes unchanged (verified IDENTICAL x2); the previous pin named a commit
off cortexkit/claustrum#28's history after its squash.

Also: biome check in the pre-commit hook errored when every staged path
was ignored, so any golden-only bump commit failed the hook. Pass
--no-errors-on-unmatched.
@iceteaSA
iceteaSA force-pushed the feat/opencode-custody branch from 898b131 to f2e65bb Compare September 2, 2026 13:30

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5 existing issues remain and 3 new issues found across 58 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/client/src/secret-json.ts">

<violation number="1" location="packages/client/src/secret-json.ts:1">
P3: This new file is dead code: nothing imports `parseSecretJson` or `ConnectionJsonParseError`, and `index.ts` never re-exports them, so package consumers cannot reach it either. The client's connection-file parsing lives in `detect.ts` via `readConnectionFile`, not here. It also duplicates `packages/opencode/src/secret-json.ts`, the copy actually used by `handles.ts`/`plugin.ts`. Remove the file or wire it into the client and re-export it.</violation>
</file>

<file name="packages/client/package.json">

<violation number="1" location="packages/client/package.json:6">
P2: This package is published as `"type": "module"` with an ESM-only `exports.import` target, but the tsc build (tsconfig.base.json uses `moduleResolution: "Bundler"`, `module: "ESNext"`) emits extensionless relative imports in dist (e.g. `export { ... } from './detect'` in dist/index.js). Node's ESM loader requires explicit `.js` extensions in relative imports, so any Node ESM consumer of the published `@cortexkit/claustrum-client` will fail with ERR_MODULE_NOT_FOUND. The in-repo consumer is bun (opencode bundles via `bun build`), which resolves extensionless imports, so the current flow works — but the published artifact is not Node-compatible. Either add `.js` extensions to the source relative imports or bundle the client before publishing.</violation>
</file>

<file name="packages/opencode/package.json">

<violation number="1" location="packages/opencode/package.json:22">
P2: The plugin bundle that OpenCode actually loads (`dist/opencode-plugin.js`, produced by `bun build` overwriting the tsc output) is never imported by any test; all tests import from `src/`. The gate only proves the bundle compiles, not that it runs, so a bundling/runtime failure (e.g. how `@cortexkit/claustrum-client` or its `@cortexkit/subc-client` dependency gets inlined) ships green. The same source is also emitted two ways — the bundled plugin versus the plain tsc `./server` and `"."` entries — and only the non-bundled path is tested. Add a test that dynamically imports the built `dist/opencode-plugin.js` (as `lifecycle.test.ts` does for `../opencode-plugin`) so the shipped artifact is exercised.</violation>
</file>

Requires human review: Auto-approval blocked because this review re-detected 5 unresolved issues already reported by Cubic.
Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/opencode/src/freshness.ts Outdated
Comment thread packages/client/src/wire.ts Outdated
Comment thread packages/client/src/identity.ts Outdated
Comment thread packages/client/src/detect.ts Outdated
Comment thread scripts/spikes/opencode-config-fetch.sh
Comment thread docs/opencode-custody-design.md
Comment thread packages/opencode/README.md Outdated
Comment thread crates/credentials-module/tests/cli_opencode.rs Outdated
Comment thread packages/opencode/src/tests/lifecycle.test.ts
Comment thread packages/opencode/src/handles.ts
@iceteaSA
iceteaSA force-pushed the feat/opencode-custody branch from f2e65bb to 98b46d4 Compare September 2, 2026 14:58

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 existing issues remain and 5 new issues found across 59 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="scripts/spikes/opencode-config-fetch.sh">

<violation number="1" location="scripts/spikes/opencode-config-fetch.sh:258">
P2: This spike can pass without the custom fetch owning the inference request: it only proves that the wrapper handled some request and that another request carried the sentinel. Record the request path or an ID and require the wrapper observation to correspond to `/chat/completions` or `/responses` before reporting success.</violation>
</file>

<file name=".github/workflows/ci.yml">

<violation number="1" location=".github/workflows/ci.yml:134">
P2: The Windows branch is not platform-agnostic: `bun test packages/client` runs a test that creates a symbolic link without handling Windows link privileges, so this CI leg can fail before completing. Exclude that test on Windows or make the fixture use a Windows-supported link strategy.</violation>
</file>

<file name="packages/opencode/src/serve.ts">

<violation number="1" location="packages/opencode/src/serve.ts:141">
P2: When a handle file is replaced during an in-flight request, this closure can send material from the old handle after ownership has changed. `verifyOwnership` runs before the asynchronous freshness lookup, and revision changes do not abort the old account; revalidate ownership immediately before every upstream forward or make a revision change invalidate the in-flight attempt.</violation>
</file>

<file name="scripts/gate.sh">

<violation number="1" location="scripts/gate.sh:119">
P2: On Windows, this arm runs the Unix-only OpenCode tests that CI intentionally excludes, so the gate cannot pass on a supported platform. Select the same platform-agnostic test subset on Windows and keep `test:hermetic` for Unix hosts.</violation>
</file>

<file name="crates/credentials-module/tests/cli_opencode.rs">

<violation number="1" location="crates/credentials-module/tests/cli_opencode.rs:44">
P2: The fake-daemon threads only poll the shutdown channel inside the `listener.accept()` select, so a daemon blocked in `read_frame` on an accepted connection cannot be interrupted by `TestDaemon::drop`'s `join()`. If a CLI invocation ever keeps a connection open without sending the expected frame, the test binary hangs instead of failing. Wrap the per-frame reads in a `select!` against the shutdown channel (or add a timeout) so teardown can always terminate the thread.</violation>
</file>

Requires human review: Auto-approval blocked because this review re-detected 3 unresolved issues already reported by Cubic.
Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/client/src/detect.ts Outdated
Comment thread scripts/accept-opencode-custody.sh Outdated
Comment thread packages/client/src/detect.ts Outdated
Comment thread packages/opencode/src/freshness.ts Outdated
Comment thread packages/client/package.json
Comment thread package.json Outdated
Comment thread docs/opencode-custody-design.md Outdated
Comment thread packages/opencode/src/tests/lifecycle.test.ts Outdated
Comment thread packages/opencode/src/tests/freshness.test.ts Outdated
Comment thread packages/opencode/src/tests/config-hook.test.ts
@iceteaSA
iceteaSA force-pushed the feat/opencode-custody branch from 98b46d4 to 86ce2a0 Compare September 2, 2026 16:13

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 existing issues remain and 4 new issues found across 60 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="tsconfig.base.json">

<violation number="1" location="tsconfig.base.json:12">
P3: The shared base restricts auto-included global types to "bun" only, which excludes @types/node. The client package is a published, plain-Node-compatible library that imports node: built-ins and relies on Node globals, and opencode uses the NodeJS namespace; tying both to Bun's global types through the shared base bakes a bun-only type environment into a general-purpose library and hides any node-global the bun types don't cover. Since @types/bun is the only @types package in scope, dropping the types array from the shared base keeps bun globals auto-included without blocking node types.</violation>
</file>

<file name="packages/opencode/src/serve.ts">

<violation number="1" location="packages/opencode/src/serve.ts:273">
P2: A legitimate same-origin redirect chain longer than six hops is rejected, even though no origin boundary was crossed. Use a documented standard redirect limit and a distinct max-redirect error so valid chains and diagnostics are not conflated.</violation>
</file>

<file name="scripts/gate.sh">

<violation number="1" location="scripts/gate.sh:306">
P2: The release build is never used by the subsequent `validation_bypass_is_absent` test, which therefore checks the debug test binary instead of the shipped artifact. Export `CRED_CLI_BIN` to the release binary before running that assertion.</violation>
</file>

<file name="packages/opencode/package.json">

<violation number="1" location="packages/opencode/package.json:7">
P3: The "exports" map omits a "./package.json" subpath, so the manifest can no longer be resolved by subpath once exports is present. Add an entry like "./package.json": "./package.json" so tooling that reads the package manifest keeps working.</violation>
</file>

Requires human review: Auto-approval blocked because this review re-detected 2 unresolved issues already reported by Cubic.
Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/opencode/src/freshness.ts
Comment thread packages/client/src/detect.ts Outdated
Comment thread packages/opencode/src/plugin.ts
Comment thread packages/opencode/src/log.ts Outdated
options.log?.error({ provider: options.provider, errorClass: refusal.name, errorMessage: refusal.message });
throw refusal;
}
if (hop === 5) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A legitimate same-origin redirect chain longer than six hops is rejected, even though no origin boundary was crossed. Use a documented standard redirect limit and a distinct max-redirect error so valid chains and diagnostics are not conflated.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/serve.ts, line 273:

<comment>A legitimate same-origin redirect chain longer than six hops is rejected, even though no origin boundary was crossed. Use a documented standard redirect limit and a distinct max-redirect error so valid chains and diagnostics are not conflated.</comment>

<file context>
@@ -0,0 +1,294 @@
+          options.log?.error({ provider: options.provider, errorClass: refusal.name, errorMessage: refusal.message });
+          throw refusal;
+        }
+        if (hop === 5) {
+          await discard(response);
+          const refusal = new CustodyRedirectRefusedError(options.provider, fromOrigin, next.origin);
</file context>

"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The "exports" map omits a "./package.json" subpath, so the manifest can no longer be resolved by subpath once exports is present. Add an entry like "./package.json": "./package.json" so tooling that reads the package manifest keeps working.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/package.json, line 7:

<comment>The "exports" map omits a "./package.json" subpath, so the manifest can no longer be resolved by subpath once exports is present. Add an entry like "./package.json": "./package.json" so tooling that reads the package manifest keeps working.</comment>

<file context>
@@ -0,0 +1,31 @@
+  "type": "module",
+  "main": "./dist/index.js",
+  "types": "./dist/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./dist/index.d.ts",
</file context>

Comment thread tsconfig.base.json
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": true,
"types": ["bun"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The shared base restricts auto-included global types to "bun" only, which excludes @types/node. The client package is a published, plain-Node-compatible library that imports node: built-ins and relies on Node globals, and opencode uses the NodeJS namespace; tying both to Bun's global types through the shared base bakes a bun-only type environment into a general-purpose library and hides any node-global the bun types don't cover. Since @types/bun is the only @types package in scope, dropping the types array from the shared base keeps bun globals auto-included without blocking node types.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tsconfig.base.json, line 12:

<comment>The shared base restricts auto-included global types to "bun" only, which excludes @types/node. The client package is a published, plain-Node-compatible library that imports node: built-ins and relies on Node globals, and opencode uses the NodeJS namespace; tying both to Bun's global types through the shared base bakes a bun-only type environment into a general-purpose library and hides any node-global the bun types don't cover. Since @types/bun is the only @types package in scope, dropping the types array from the shared base keeps bun globals auto-included without blocking node types.</comment>

<file context>
@@ -0,0 +1,14 @@
+    "skipLibCheck": true,
+    "resolveJsonModule": true,
+    "declaration": true,
+    "types": ["bun"]
+  }
+}
</file context>

Comment thread packages/opencode/src/tests/lifecycle.test.ts Outdated
Comment thread crates/credentials-module/tests/cli_opencode.rs Outdated
Comment thread crates/credentials-module/tests/common/mod.rs Outdated
@iceteaSA
iceteaSA force-pushed the feat/opencode-custody branch from 86ce2a0 to 0e9dee7 Compare September 2, 2026 17:27
@ckcred-alfonso

ckcred-alfonso Bot commented Sep 2, 2026

Copy link
Copy Markdown

Verified the fix at 898b131 rather than reading the description. Both helpers are right, and the sixth site you found is the better catch — my review stopped at the four I could see from the failure path, and add's comparison-only handle was the one that never appeared in a diff I was reading.

mint_handle( call sites   definition + the two helpers, nothing else
mint_then_persist         revokes on persist failure; keeps the handle on success,
                          which is correct — that one is meant to live in the file
with_scoped_handle        revokes on BOTH arms, and propagates a revoke failure
                          on the success path rather than swallowing it
let _ = revoke_handle     zero hits

The two-helper split is the part I would have got wrong if I had written it myself: one keeps the handle on success and one never does, and collapsing them into a single "always revoke" helper would have broken the migrate path silently.

One gap, and it is in a guard of mine rather than in your fix

The seam is a cargo feature, and this repo's standing rule for test escape hatches is #[cfg(debug_assertions)] precisely because a feature can be switched on in a release build by a Cargo.toml edit nobody reviews as security-relevant. That rule exists because of CORTEXKIT_TEST_BYPASS_VALIDATION, and it came with a release-binary scan asserting the hatch is absent, positive control included.

Your strings count is the right check. It was run by hand once. The automated version already exists and does not know about you:

seam env vars in the new code    CK_OPENCODE_TEST_FAIL_GET_MATERIAL
                                 CK_OPENCODE_TEST_FAIL_HANDLE_WRITE
                                 CK_OPENCODE_TEST_FAIL_REVOKE
                                 CK_OPENCODE_TEST_FAIL_TOMBSTONE_REREAD
strings the release scan asserts CORTEXKIT_TEST_BYPASS_VALIDATION
absent from the release binary

Four hatches, one asserted. The scan is honest about its own subject and blind to everything added after it was written, which is the failure mode I have been chasing across this repo all week: a guard whose population is hardcoded stops being a guard for whatever arrives next, and reads as covering it.

Adding the four strings to that test closes it and is the smallest correct change. What I would rather have — and would take as a follow-up rather than a condition — is the population derived: scan the source for CK_*_TEST_* env reads and assert each is absent from the release binary, so a fifth hatch arms the guard without anyone remembering to. That has the known limit of any source scan (it sees the literal form and not a name built another way), but it fails in the safe direction: it cannot be worse than a hardcoded list of one, and the positive control still proves the scan can see.

Not blocking on the derived version. Blocking on the four strings, because the alternative is that the next hatch inherits a green check that never looked at it.

Closed since my first pass

The lifecycle.test.ts byte-identical assertion is gone — the orphan case now asserts rejects.toThrow("migrate-opencode"), which is a claim JSON.stringify cannot silently satisfy. That was the one thing I wanted closed regardless of the rest, and it closed without my asking.

(Re-checked at 0e9dee7. The branch has moved four times while this sat in my queue; I re-ran the seam-string comparison at each head rather than let the finding age, and it reads the same at this one.)

@iceteaSA

iceteaSA commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author

Live on the deploy box — 2026-09-02 18:45–18:59Z

Ran the operator procedure from this branch (0e9dee7) against the real daemon and the real ~/.local/share/opencode/auth.json on OpenCode 1.18.26. No secrets below; handles and keys were never printed.

Migration (ck-auth migrate-opencode --provider …, one provider at a time, dry run first):

provider credential verdict chain rows
synthetic apikey:synthetic:main v1 identical (record pre-existed from the T9 acceptance run; material matched byte-for-byte) seq 896 mint_handle
deepseek apikey:deepseek:main created seq 897 import, 898 mint_handle
minimax-coding-plan apikey:minimax-coding-plan:main created seq 899 import, 900 mint_handle

After: the three api entries in auth.json are claustrum-tombstone:v1:<provider>; the four oauth entries are byte-unchanged (per-field hashes compared against the pre-migration backup); file mode 600 preserved. Handle file created at mode 600 with serve: "opencode-claustrum", one 47-char handle per provider. Dry run wrote nothing (sha unchanged, no handle file).

Proof — plugin registered from packages/opencode/dist/opencode-plugin.js, one session restarted, then:

opencode run -m synthetic/hf:moonshotai/Kimi-K3     'Reply exactly CUSTODY_MIGRATION_OK.'  → CUSTODY_MIGRATION_OK
opencode run -m deepseek/deepseek-v4-flash          'Reply exactly CUSTODY_MIGRATION_OK.'  → CUSTODY_MIGRATION_OK
opencode run -m minimax-coding-plan/MiniMax-M3      'Reply exactly CUSTODY_MIGRATION_OK.'  → CUSTODY_MIGRATION_OK

with auth.json holding only the sentinels at request time (checked after each call). Vault side after the three requests and a fleet-wide restart of every OpenCode session: chain tip still 900 (successful gets write nothing), 0 auth_events, 0 fetch-anomaly alarms, verify-audit intact, oauth:anthropic / oauth:anthropic:work-alt untouched.

Two things learned that are not in the branch:

  1. migrate-opencode re-serializes auth.json with sorted keys. Any order-sensitive digest of another provider's entry (jq -c) sees a phantom change; a key-sorted digest (jq -cS) does not. Worth a line in the operator doc; a writer that preserves key order would remove the effect entirely.
  2. The doc's proof example uses deepseek/deepseek-chat, which no longer exists in the model list (deepseek-v4-flash works). Doc-only.

Sessions started before the migration keep working on their in-memory key until restarted; Auth.set writes from other plugins read the file fresh and did not clobber the tombstones (observed across the anthropic-auth fleet restart at 18:40Z).

@iceteaSA

iceteaSA commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author

Correction to the comment above — two of the three migrations were reversed at 19:10Z.

The migration premise "model traffic is unaffected; only tok's balance/quota readers see the tombstone" was wrong on this box: the insula quota module also reads static API keys directly from auth.json (deepseek.rs:180, synthetic.rs:526opencode_auth::read_provider) and sent the tombstone as its bearer → 401 on its deepseek and synthetic quota lanes from the moment of the write. Model traffic through OpenCode was fine throughout (the three proofs stand). minimax-coding-plan is unaffected (insula's minimax lane is env-only) and stays under custody.

migrate-opencode --restore deepseek / --restore synthetic (the branch's own rollback verb) wrote the real keys back — hash-identical to the pre-migration backup — revoked the two serving handles (seq 901, 902), kept the vault records, mode 600 preserved. First live exercise of --restore; it did exactly what the doc says.

Doc consequence for the operator procedure: the precondition step must enumerate every reader of auth.json on the host, not just OpenCode and tok — anything that calls read_provider on a static key becomes a tombstone consumer. On this host that is insula until it gains an apikey:* vault lane (its issue to file).

@iceteaSA
iceteaSA force-pushed the feat/opencode-custody branch from 0e9dee7 to 2c39e3d Compare September 2, 2026 19:36
@iceteaSA

iceteaSA commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author

Closed at 2c39e3d, both halves.

Blocking half: the release scan now asserts all five seam strings absent. Preferred half, delivered: the population is derived — the test scans crates/ for "CK_*_TEST_*" / "CORTEXKIT_TEST_*" string literals, requires ≥5 hits (so an empty scan cannot pass), and asserts each absent from the release binary; positive control kept. Known limit stated at the test: a name built by concatenation is invisible to a literal scan — fails safe (never worse than the hardcoded list).

Your standing rule, adopted: the four seams moved from the opencode-test-seam cargo feature to #[cfg(debug_assertions)]; the feature is gone from Cargo.toml, gate.sh and CI (15 sites, zero remaining references). The seam tests now run in the default debug profile (cli_opencode 54/54; workspace floor re-measured 525 and set to it — thanks for the floor note, it moved twice today).

Red-checks: (a) release build with RUSTFLAGS='-C debug-assertions=on' — the exact way a hatch now reaches a release binary — the scan fails naming all four CK_OPENCODE_TEST_FAIL_* plus the bypass; (b) narrowing the source regex to four names trips the population floor. Default release strings count: 0.

Also since your first pass: three static-key providers were migrated on the deploy box and one request answered through each with only the tombstone in auth.json (comment above); two were reversed because another local reader of auth.json (insula's quota lanes) had not been enumerated — doc consequence noted there, --restore did its job on first live use.

@ckcred-alfonso

ckcred-alfonso Bot commented Sep 3, 2026

Copy link
Copy Markdown

Converting the four seams from a cargo feature to #[cfg(debug_assertions)] is a better answer than the one I asked for. I proposed adding four strings to the release-binary scan; you removed the reason the scan needed them. Verified at 2c39e3d:

Cargo.toml                 -[features] opencode-test-seam = []   deleted
opencode_migration.rs      4x  #[cfg(feature = "...")]  ->  #[cfg(debug_assertions)]

And the two changes compose, which I measured rather than assumed. Master now derives that scan's population from source instead of asserting one hardcoded string (169bef1) — I found that gap by reviewing your branch, so it is your finding one level up. Running my extractor against your file:

CK_OPENCODE_TEST_FAIL_GET_MATERIAL
CK_OPENCODE_TEST_FAIL_HANDLE_WRITE
CK_OPENCODE_TEST_FAIL_REVOKE
CK_OPENCODE_TEST_FAIL_TOMBSTONE_REREAD

All four are found, because all four names carry TEST. So you do not need to touch the scan at all — rebase and it covers your hatches automatically.

I checked that in a throwaway worktree: your branch plus master's cli_admin.rs, real --release build, and the test passes. But a pass is the ambiguous outcome — it is satisfied both by "the four hatches are correctly compiled out" and by "the scan never saw them". So I removed one of your #[cfg(debug_assertions)] gates:

test hatch CK_OPENCODE_TEST_FAIL_TOMBSTONE_REREAD is present in the release
ck-auth binary; it must be compiled out under #[cfg(debug_assertions)]

Fires by name. Gate restored, worktree gone. So the composed pass is real rather than vacuous.

One thing worth knowing since you now depend on it: the extractor matches names containing TEST, BYPASS or SEAM, which is this repo's convention — a hatch named outside it is invisible to the scan. Your names satisfy it by luck rather than by having been told, so it is stated at the site now. If you add a fifth hatch, keep TEST in the name.

Still open from my last pass: #30's lockfile regression is unrelated to this branch, and this branch is based on master before 169bef1, so a rebase is worth doing before merge for both reasons.

@iceteaSA
iceteaSA force-pushed the feat/opencode-custody branch from 2c39e3d to 2c2e713 Compare September 3, 2026 05:56
@iceteaSA

iceteaSA commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto 169bef1 — head 2c2e713, one commit, lock identical to master's.

Your 169bef1 and my round both derived the hatch population from source, so I dropped mine and kept yours (shipped_test_hatch_env_names + anchor); the #[cfg(debug_assertions)] conversion of the four CK_OPENCODE_TEST_FAIL_* seams and the removal of the opencode-test-seam feature stay, since your commit didn't do that half. Proven against your scan: a release build with RUSTFLAGS='-C debug-assertions=on' fails the test naming all four (exit 101); the default release build carries zero of them.

Same squash defect as on #30 applied here (2c39e3d had silently reverted 3568159/7be8c10/3b1da78/a31a34b); rebuilt from the true diff, all four restored, verified by symbol. Bun 146/146, cli_opencode 54/54, release-artifact 1/1, workspace floor 525, gate green; goldens, bun.lock and the plugin source byte-identical to the previous head, so the pinned consumers (#182, the OpenAI seat's vendored copy) are unaffected.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 6 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/client/src/manifest-lock.ts">

<violation number="1" location="packages/client/src/manifest-lock.ts:331">
P2: On Windows, the default handle-file path contains `\`, so `path.split('/')` leaves the whole absolute path in the temporary filename. The resulting filename is invalid and every manifest write fails; derive the name with `basename(path)` or strip both separators.</violation>

<violation number="2" location="packages/client/src/manifest-lock.ts:343">
P2: A crash immediately after this rename can lose the new directory entry because the containing directory is never synced. Sync the parent directory after publication, as the Rust writer does, so handle-file updates remain durable across crashes.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/client/src/manifest-lock.ts Outdated
await chmod(temporary, 0o600)
await testOptions?.beforeManifestRename?.(`${path}.lock`)
await commitLease()
await rename(temporary, path)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A crash immediately after this rename can lose the new directory entry because the containing directory is never synced. Sync the parent directory after publication, as the Rust writer does, so handle-file updates remain durable across crashes.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/client/src/manifest-lock.ts, line 343:

<comment>A crash immediately after this rename can lose the new directory entry because the containing directory is never synced. Sync the parent directory after publication, as the Rust writer does, so handle-file updates remain durable across crashes.</comment>

<file context>
@@ -0,0 +1,374 @@
+    await chmod(temporary, 0o600)
+    await testOptions?.beforeManifestRename?.(`${path}.lock`)
+    await commitLease()
+    await rename(temporary, path)
+  } finally {
+    await handle?.close().catch(() => {})
</file context>

Comment thread packages/client/src/manifest-lock.ts Outdated
const parent = dirname(path)
const bytes = Buffer.from(JSON.stringify(file))
if (bytes.byteLength > HANDLE_FILE_MAX_BYTES) throw new Error('handle file exceeds 256 KiB')
const temporary = join(parent, `.${path.split('/').pop()}.${process.pid}.${randomToken()}.tmp`)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: On Windows, the default handle-file path contains \, so path.split('/') leaves the whole absolute path in the temporary filename. The resulting filename is invalid and every manifest write fails; derive the name with basename(path) or strip both separators.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/client/src/manifest-lock.ts, line 331:

<comment>On Windows, the default handle-file path contains `\`, so `path.split('/')` leaves the whole absolute path in the temporary filename. The resulting filename is invalid and every manifest write fails; derive the name with `basename(path)` or strip both separators.</comment>

<file context>
@@ -0,0 +1,374 @@
+  const parent = dirname(path)
+  const bytes = Buffer.from(JSON.stringify(file))
+  if (bytes.byteLength > HANDLE_FILE_MAX_BYTES) throw new Error('handle file exceeds 256 KiB')
+  const temporary = join(parent, `.${path.split('/').pop()}.${process.pid}.${randomToken()}.tmp`)
+  let handle: Awaited<ReturnType<typeof open>> | undefined
+  try {
</file context>
Suggested change
const temporary = join(parent, `.${path.split('/').pop()}.${process.pid}.${randomToken()}.tmp`)
const temporary = join(parent, `.${path.replace(/^.*[\\/]/, '')}.${process.pid}.${randomToken()}.tmp`)

@iceteaSA
iceteaSA force-pushed the feat/opencode-custody branch from d69ceed to 21fb7bf Compare September 3, 2026 14:25
@ckcred-alfonso

ckcred-alfonso Bot commented Sep 3, 2026

Copy link
Copy Markdown

Gated 21fb7bf, then 9acaba7 when it landed mid-review. The full gate passes on the current head — and I am not calling the lock clean, because one run failed and I cannot reproduce the condition.

The flake

21fb7bf, inside the full gate (load 38.65)   FAILED
21fb7bf, 9 isolated runs (load 16-24)        8 passed, 1 failed
9acaba7, 8 isolated runs (load 11.6)         8 passed
9acaba7, 6 parallel runs (peak load 13.8)    6 passed
9acaba7, full gate (load 25.9)               PASSED
opencode_files.rs:1342  called `Result::unwrap()` on an `Err` value:
                        Invalid("manifest lock busy")
test  two_stale_evictors_have_one_winner_and_non_overlapping_holders

The duration split is the tell: every pass lands in 0.06–0.11s, the failure took exactly 30.00s — a wait window expiring, not an assertion missing.

What I have not established, stated plainly: the green runs above do not clear it. My own data shows this test passes at low load, so passing at load 11–26 answers a question I already knew the answer to. The one failure was at 38.65, and I could not manufacture that again — parallel copies finish in 0.09s each and never build sustained load. 9acaba7 also is not a fix; it is another feature, and it touches opencode_files.rs (+51 −7) without addressing this.

So the state is: observed once, load-correlated, unreproduced on the newer head under lighter load. A CI runner is a loaded shared machine, which is a worse place to meet it.

I am not going to tell you which side it is on — a race in the eviction path, or a test asserting a scheduling window the machine does not guarantee. You wrote both. But a lock's own concurrency test failing once is not something I will merge past: if the test is wrong the lock is unproven, and if the lock is wrong it is wrong in exactly the case it exists for. The failing path is two evictors racing on a stale lock — the path that only runs after something already went wrong, which is the least-exercised and worst-timed place for a defect.

Scope, as a judgement rather than a finding

The PR moved twice while I was reviewing it2c2e71321fb7bf9acaba7 — each adding a feature I had not asked for: the cross-tenant writer lock, the handle-file resolver export, and now plugin-export migration.

62 files, +13404
  generated/vendored      921
  hand-written         12483
existing vault src     2 files, +153 -181

The size does not alarm me. It is overwhelmingly additive, and the delta in existing vault code is ~180 lines across two files I have already read — a far safer shape than a 12k rewrite.

Convergence does. Review cannot finish on a target that grows faster than it is read, and I have now gated three heads of this PR in one sitting, two of which were superseded before I could report. That is my time and yours spent on work neither of us keeps.

So: freeze here. Fix the flake, and let anything not required by the custody path itself ride a follow-up. If the lock and the resolver export are required, say so and I will review them as part of this — but then nothing further goes in until it merges.

#30 at 20c7920 gates clean, full green, floor matching. That one I am ready to merge.

@iceteaSA
iceteaSA force-pushed the feat/opencode-custody branch from 9acaba7 to 79b62da Compare September 3, 2026 18:43
@iceteaSA

iceteaSA commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author

Frozen, as asked. PR #28 head is now 79b62da: the custody commit alone, rebuilt on master d340ce0 (your #30 merge + the 0.16.3 lock wave), one commit, nothing else.

What left the PR. The writer lock, the handle-file resolver export, mint-handle --out, and migrate-plugin are not required by the custody path; they went in because two downstream plugins were waiting on a lock sha and this branch was the sha I had in hand. Wrong vehicle. They are preserved at tag followup/manifest-lock-and-exports (= 9acaba7) on the fork and will come as separate PRs after this merges, lock first.

The flake stays with the lock, and I am taking your reading of it: a 30.00 s failure is the claim window expiring, not the 1 ms stale margin I had addressed, and green runs at load 11–26 do not clear a failure at 38. Neither of the two implementer runs that saw it today should have been closed on a rerun; that was my call, not the reviewer's. Root cause before the lock PR opens.

One correction to 79b62da vs the head you read (2c2e713), which you would have found: my afternoon rebase onto 4834b7a resolved the ci.yml conflict by taking master's file and silently dropped this PR's Bun CI steps — 9acaba7 was missing them. 79b62da is rebuilt from master + the true PR diff (git diff 169bef1 2c2e713), so the steps are back; ci.yml keeps your rendered-name fix. Both sweeps on the result: every line it removes against master is one 2c2e713 already removed, and the only PR line it lacks is the name: you renamed.

Gate on 79b62da: GATE PASSED, workspace floor re-measured 556 (cargo test --locked --workspace, debug), Bun hermetic 146/146 with and without $HOME, default release seam count 0, Cargo.lock identical to master's.

@iceteaSA

iceteaSA commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author

Root cause of the two_stale_evictors… flake, since you asked and declined to guess: lock defect, not the test. Proven on a scratch tree at 9acaba7 (the code is off #28; reporting here because this is where you observed it).

Mechanism — an ABA on the eviction rename. Two evictors both read stale owner S0. A renames S0 to its quarantine target, re-mkdirs the lock, publishes fresh owner A1, starts its hold. B resumes with its stale S0 observation and calls rename(lock, <lock>.stale-<S0.ts>-<fresh random>). The rename has no identity precondition, and because every attempt drew a fresh random target it succeeds against A1. B's post-rename owner check sees the mismatch, but A1 has already left the canonical path; A's release reads lock/ownerENOENT → lease-lost no-op (correct per contract); B restores A1 to the path. A fresh-looking directory whose owner has departed now sits at lock. Under the test's injected fixed clock it never ages, and the claim deadline is monotonic (Instant::now() + ttl, not the injected clock) — hence exactly 30.00 s, then manifest lock busy. In production the wall clock ages it out after one TTL: a bounded, loud, real failure after a crash-eviction race. The same race also surfaced under load as set_mode(lock)ENOENT: a claimant's fresh dir renamed from under it before it could chmod.

Evidence. 64 yes on 16 cores (1-min load 42→72): 1/50 spontaneous, the set_mode/ENOENT shape. A deterministic barrier seam at the rename/release gap forces the exact 30.00 s busy on demand. With the fix: 50/50 at load 84.

Fix (semantics-preserving, 94+/3−, one file). The quarantine suffix becomes the observed owner's nonce instead of a fresh random. Every racer that saw S0 targets the same name; the delayed loser's rename then collides with A's occupied, non-empty quarantine dir (ENOTEMPTY) and is treated as a lost race. Target format unchanged. A #[cfg(test)] post-rename seam plus the ABA regression pin it.

This is a contract change for the TS writer and the sibling plugin's implementation too — both draw a fresh random target per attempt and carry the same race. It lands with the lock PR, not here.

…h proxy

OpenCode keeps provider API keys in auth.json and reads them per request.
This moves custody of those keys into the vault: the key is replaced on
disk by a non-secret tombstone (`claustrum-tombstone:v1:<provider>`), a
capability handle lives in a mode-600 handle file, and a plugin on
OpenCode's `config` hook injects `{apiKey: <tombstone>, fetch}` so the
request-time closure substitutes the served credential wherever the SDK
placed the sentinel. The plugin holds zero provider knowledge; ownership
is the conjunction of a tombstone in auth.json and a `serve` claim in the
handle file.

Rust (ck-auth):
- `migrate-opencode` (dry-run, `--provider`, `--restore`, `--replace`,
  `--force-shape`) and `opencode-account add|remove|list`, online against
  the daemon or offline on the lease; every write is temp+fsync+rename at
  mode 0600 in a checked parent; a `superseded` journal on the handle
  file makes a crash between tombstone write and revoke converge without
  rotating handles.
- Shared `route_client.rs` transport and a capability-only
  `credential_client.rs`; no new admin op and no secret-returning surface.
- Rust handle-file validation mirrors the TS parser rule for rule
  (provider/label charset, `ckh_` base64url handles, `superseded`
  entries); provider ids never `__proto__`/`constructor`/`prototype`.
- `opencode-provider-shapes.json`: providers whose key leaves OpenCode's
  fetch seam (env copy, discovery, metadata) are refused at migrate time
  with the shape, the reason, the source citation, and the consequence of
  forcing; `ck auth usable` warns on an existing tombstone whose shape
  moved. Data with provenance (anomalyco/opencode@dc4449df0d, method and
  its edge stated), maintained by delta per OpenCode base update.
- Vault import refuses a tombstone as credential material.
- `opencode-test-seam` feature (two env seams) for crash-cut tests;
  compiles nothing into release, and gate.sh ends on a default build.

TypeScript:
- `@cortexkit/claustrum-client`: detect, wire, identity, reconnect and
  error classes extracted from anthropic-auth; policy-free.
- `@cortexkit/claustrum-opencode`: seven-cell ownership table (split
  custody installs a REFUSING fetch; orphan injects nothing), ordered
  per-account failover with 401 reporting fenced on the served
  record_version, 429/402 cooldowns, manual same-origin-only redirects,
  bounded warm and a 60 s oauth tick for idle-account custody, redacted
  logging with canary tests, and a lifecycle suite driven through the
  exported plugin (`dist/opencode-plugin.js`, v1 `{id, server}` shape).
- Fail-closed on every path that sees a tombstone: unreadable or oversized
  files, unrecognised native-runtime flag values, absent handle file.
  auth.json past the parse cap is scanned for sentinels with each hit
  becoming a refusal directly. `readAuth` mirrors `Auth.all` precedence
  including its error behaviour.
- A containment property test asserts the plugin's refusal set is a
  superset of every provider OpenCode would load with a sentinel in it,
  over auth-source × handle-state rows, against a reference model of the
  host derived from its source (not from the plugin) and proven so by a
  two-sided mutation.

Gates: bash scripts/gate.sh with bun arms (install, typecheck, build,
test) ahead of the cargo arms; workspace floor measured in the profile
the gate runs. Live acceptance in scripts/accept-opencode-custody.sh:
migrate, serve a real model call through the vault, refuse a hand-restored
key as split custody, restore — against the running daemon in a scratch
XDG home.

Review response folded in: provider ids are validated where they are
MATERIALIZED (one function at every cfg.provider write; auth.json keys
were a third, unvalidated site); parse errors on secret-bearing files
(auth.json, the handle file, the daemon connection file) never echo
parser text — Bun's SyntaxError quotes the input token, which put a
handle verbatim into a thrown message; catch sites log a fixed code and
the error name, never the message; the handle file is read once through
an O_NOFOLLOW descriptor, fstat-validated, revision from the same bytes;
query substitution percent-encodes the material and leaves untouched
parameters byte-identical; 303-to-GET drops the RFC 9110 representation
headers; connection-file discovery mirrors the daemon's order; the
acceptance script arms rollback before migrating.

Handle lifecycle (maintainer finding): a minted handle never outlives
the operation that minted it. `mint_then_persist` revokes the handle if
its file write fails; `with_scoped_handle` revokes a comparison-only
handle on every exit; both name the credential id and the closing
commands if the revoke itself fails. No bare mint remains outside the
two helpers. Rebased onto master's redacted-Debug change (0679dea).

Design: docs/opencode-custody-design.md. Follow-ups: cortexkit#29.

The TypeScript suite runs hermetically (no daemon, no HOME) and that run is the gate
in scripts/gate.sh and both CI jobs; migrate-opencode and opencode-account add refuse
keys carrying the reserved tombstone prefix; the auth.json read is single-descriptor
bounded.

A rejection from a stale handle revision cannot poison the replacement slot, a stalled
get expires instead of pinning the slot, descriptor reads are bounded to the cap on the
bytes actually read, percent-encoded sentinels match case-insensitively, the client
ships Node-loadable ESM, and the hermetic suite runs on every CI leg including Windows.

Connection discovery mirrors the daemon tier-for-tier and refuses an ambiguous match;
the serve path renders error names only, with a structured code for callers and a canary
covering the substitution-failure arm; a stalled tick warm expires like a request warm.
@iceteaSA
iceteaSA force-pushed the feat/opencode-custody branch from 75c10f6 to 5d2e43e Compare September 4, 2026 15:41
@ckcred-alfonso

ckcred-alfonso Bot commented Sep 4, 2026

Copy link
Copy Markdown

Gated 5d2e43egreen, all arms, and I checked what the green means rather than taking it.

The flake

Your ABA diagnosis is the answer to the question I declined to guess at, and it lands on the side I said would be the worse one: the lock, not the test. The rename having no identity precondition, every racer drawing a fresh random target so B's stale-owner rename succeeds against A1, and the monotonic claim deadline explaining the exact 30.00 s — that chain accounts for every observation I had, including the one I could not reproduce.

Your fix reasoning is what I would have wanted: making the quarantine suffix the observed owner's nonce turns the race into an ENOTEMPTY collision, so every racer that saw S0 targets one name. That is eliminating the class rather than widening a window.

But the green here is not evidence about that fix, and I want to be explicit since a pass is the ambiguous outcome:

manifest lock files in this PR   0
two_stale_evictors               absent
manifest_lock refs               absent
control: opencode_files          present, 4 files

The lock and its test left the PR. So this suite passes because the subject is gone, not because the race is fixed — which is the correct outcome for the scope freeze, and it means the ABA fix still needs its own review wherever it lands. I have not reviewed it and am not counting it as reviewed.

Scope

before the freeze   62 files, +13404
now                 59 files, +11453   (identical across the last two heads)

Shrunk, then held across a rebase onto 518fbb3. That is the freeze doing exactly what I asked for, and it is why this review could finish — the previous three could not, because the target grew faster than I could read it.

Seam hatches: closed, and composed

You converted all four from a cargo feature to #[cfg(debug_assertions)], which is this repo's rule for the reason that a feature can be flipped by a Cargo.toml edit nobody reviews as security-relevant. Verified on the real bytes rather than the source:

CK_OPENCODE_TEST_FAIL_GET_MATERIAL      0
CK_OPENCODE_TEST_FAIL_HANDLE_WRITE      0
CK_OPENCODE_TEST_FAIL_REVOKE            0
CK_OPENCODE_TEST_FAIL_TOMBSTONE_REREAD  0
control ("already provisioned")         4

Master's release scan derives its population from source now, and all four names carry TEST, so they are covered without you touching it.

Where this leaves the merge

Everything I raised is closed or has left the PR. My remaining reservation is not about the code: #17 was closed as "superseded by PR #28", which makes this PR the only record of that tracked need. If it stalls, the need disappears with the closed issue rather than reverting to an open one. That is an argument for landing it, not against.

I am ready to merge this. Say when you consider it final — given it has moved under four of my reviews, I would rather have your word than infer it from quiet.

@iceteaSA

iceteaSA commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator Author

Final at 5d2e43e. You asked for the word rather than inferring it from quiet, so: I am not adding anything else to this branch, and anything further goes to a follow-up PR.

State I verified before saying so, rather than asserting from memory: head matches the fork, tree clean, one commit whose parent is 518fbb3 = current master, 59 files / +11453 / -242 — the same shape you counted. Deletions are all the branch's own refactor (163 from admin_client.rs for the route_client.rs extraction, 60 cli_admin.rs, 11 ci.yml, 5 gate.sh, 2 credentials_cli.rs, 1 tests/common/mod.rs). Gate green on this exact tree at floor 561, Bun 146/146 with and without $HOME, seam scan 0.

One correction to my own verification, since it is the kind that passes while guarding nothing. My first revert sweep printed 232 hits and I nearly shrugged at it: I had run it with 518fbb3 as the "old base", and 518fbb3 is master, so the upstream-added set was empty and every deletion in the PR scored as a hit. Re-run against the real old base (61f92f6, what the branch sat on before this morning's rebase), the intersection is five lines: three bare } from content-level matching, and the run_expect 506 line with its comment — which is the floor line this PR deliberately rewrites to 561. So nothing upstream is reverted. I also re-checked the ci.yml Bun steps by name, because an earlier rebase of mine silently dropped them by taking master's side of that conflict; they are present at this head.

On the lock

Your reading is right and I want it recorded as yours, not softened: this suite is green because the subject left the PR. The lock, two_stale_evictors, and the ABA fix are all absent here, so nothing in this run is evidence about that race. The fix exists as a patch against the follow-up branch and has had no review; it lands as its own PR with its own reproduction, and I will not treat this merge as having blessed any part of it.

Why the timing matters, briefly

The reserved-prefix refusal in this PR (CUSTODY_TOMBSTONE_PREFIX, oauth.rs) is load-bearing outside this repo. The anthropic-auth seat is taking main-account custody, which writes a tombstone into auth.json and drops the local copy — and my unattended re-sealer reads that same entry as its seal input. I proved on a scratch vault that the currently deployed ck-auth accepts a tombstone as real material and answers replaced oauth:anthropic, so on flip day one 401 in the window would seal the tombstone over the live record while the plugin has already dropped its copy. Both copies of the operator's primary credential gone, and by your own ruling on #32 recovery is interactive-only.

That seat has agreed to hold the tombstone write until my master carries this refusal. So the merge is the first link in that ordering rather than a convenience — no pressure on the timing, but you should know a cross-repo plan is gated on it rather than merely waiting for it.

@ckcred-alfonso

ckcred-alfonso Bot commented Sep 4, 2026

Copy link
Copy Markdown

Final at 5d2e43e accepted — that is the exact head I gated and reviewed, so nothing has moved under this pass. Your revert-sweep self-correction is the right kind: a sweep whose "old base" is master has an empty upstream set, so every deletion scores as a hit and the 232 was a fact about the instrument. Re-running against 61f92f6 is what made it an answer.

I verified your merge argument on my own deployed binary rather than taking it, because it is a claim about my custody surface:

put --id oauth:anthropic --payload 'real-token'                    created
put --id oauth:anthropic --replace --payload 'claustrum-tombstone…' replaced (unconditional)
usable                                                              static, active, 0d ago

Confirmed. The deployed ck-auth seals a tombstone as real material and reports success, and the record then reads healthy. On flip day that is the operator's primary credential gone from both custodians, with interactive-only recovery per my own #32 ruling.

One finding, and it does not block the merge

The refusal does not cover the door I just walked through. Grepped, then built your branch and ran the same command against it:

5d2e43e: put --replace --payload 'claustrum-tombstone:v1:anthropic'
  -> replaced oauth:anthropic (unconditional)

CUSTODY_TOMBSTONE_PREFIX is enforced in opencode_accounts.rs (--key-file) and opencode_migration.rs, and appears zero times in credentials_cli.rs, store.rs, record.rs and admin_ops.rs. So generic put --replace — and every other write that does not go through the OpenCode verbs — still accepts a tombstone as material at this head.

That is not an objection to what you built: your re-sealer goes through the OpenCode path and is covered. But the hazard as you described it is "material reaches the vault from a source that has tombstoned it", and the guard is on one entrance. The write sink is where it belongsstore.rs, or VaultRecord::new_static, alongside the empty-payload rejection that already lives there for exactly this reason: a sink guard cannot be bypassed by a new caller, and the next import source or verb inherits it for free.

I am not asking you to add it here. Scope is frozen, this is a follow-up, and I would rather merge the covered path today than reopen a branch that is finally still.

Merging

Everything I raised is closed or has left the PR. Your statement that the green here is not evidence about the ABA fix is recorded as yours and I hold you to it in the good sense: that fix arrives as its own PR with its own reproduction, and this merge blesses none of it.

Merging now.

@ualtinok
ualtinok merged commit 4d7c4e8 into cortexkit:master Sep 4, 2026
6 checks passed
ualtinok added a commit that referenced this pull request Sep 4, 2026
… be printed

First half of the zeroize work on issue #29. This lands the TYPE; converting the
credential fields to it is the next commit.

I DISPROVED MY OWN RECORDED BLOCKER TO GET HERE. I had published on that issue that
wrapping these fields "touches the serialized type on sealed records" and would need a
migration, and deferred on it. Zeroizing's serde impls delegate straight to the inner
type, so the wire form is byte-identical -- proven both directions, including that
records sealed before the wrapper existed load into a wrapped field unchanged. There is
no migration. A recorded obstacle nobody re-tests is the class I keep finding in other
people's work; this one was in my own published reasoning.

WHY A NEWTYPE AND NOT A BARE Zeroizing. Three properties are needed and Zeroizing
supplies one:

  scrubbed on drop              Zeroizing yes    Secret yes
  serialises byte-identically   Zeroizing yes    Secret yes
  cannot be printed             Zeroizing NO     Secret yes

Zeroizing derives Debug from its inner type, so `{:?}` on a wrapped token prints the
token -- quietly undoing the redacted-Debug work that closed the first half of this same
hazard. Unprintable BY CONSTRUCTION cannot be un-redacted by someone adding a derive.

`expose()` IS A NAMED METHOD, NOT A Deref, AND THAT IS THE DESIGN. Deref would make
every read invisible and reduce this to decoration: the audit question "where does secret
material leave its scrubbed buffer" would have no mechanical answer. With expose, the
answer is one grep, forever.

WHAT IT DOES NOT DO, stated at the top of the file so nobody reads more into it: a
Secret scrubs the buffer it owns and cannot reach a copy someone made and stored. That
is why the conversion commit matters more than this one -- 29 clone sites and 1 payload
clone are the real surface, and a wrapper without that pass would read as complete
protection while most copies stayed in the clear.

Threat model recorded honestly too: this is defence in depth against reading memory
AFTER the value is dead. It does nothing against an attacker reading live process memory
at the moment of use.

Floor to 564 (PR #28 brought its own tests in at the merge).
ualtinok added a commit that referenced this pull request Sep 4, 2026
…les on windows

Master stayed red after the TMPDIR fix, on a DIFFERENT windows-only defect in the same
push: `cli_opencode.rs` used `std::os::unix::fs::PermissionsExt` at 12 sites with no
`cfg(unix)`, so windows failed with 13 compile errors (E0433/E0599). The file arrived
with PR #28, which I merged after gating it green -- on macOS, which IS unix, so my gate
could not have caught it.

TWO CATEGORIES, TREATED DIFFERENTLY, because they are not the same thing:

  SETUP    10 sites   -> set_mode(), a no-op off unix
  ASSERTION 3 sites   -> cfg(unix), removed entirely off unix

The setup sites degrade to a no-op because the tests using them check parsing, custody
and lifecycle behaviour that is identical on every platform -- they were unreachable on
windows only because a setup line would not compile. The assertions do NOT degrade: a
portable stub returning a fake 0o600 would pass on a platform where mode bits do not
exist, and a reader asking "is the handle file private everywhere" would get a yes that
means nothing.

PROVEN LOCALLY RATHER THAN BY PUSHING AND HOPING. `cargo check --target
x86_64-pc-windows-msvc` cannot reach this file (ring's build script needs MSVC), but the
two helpers extracted into a standalone file can be type-checked for the windows target
directly:

  new shape, windows target   rc=0, 0 errors
  control, gates removed      rc=1, 5 errors -- E0433, CI's exact error

The control is the point: without it a passing probe is equally consistent with a probe
that checks nothing.

Also recorded in gate.sh what it cannot see: this gate runs on one platform and CI runs
on three, and two defect classes in one day were invisible here by construction.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants